iT邦幫忙

2024 iThome 鐵人賽

DAY 18
1
Modern Web

Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器系列 第 18

Day 18: 使用 Vue Router 實現多級嵌套路由與導航守衛

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20240923/20117461hfv8pzoc1V.jpg

簡介

在現代的單頁應用程序(SPA)中,路由管理是一個核心功能。Vue Router 不僅提供了基本的路由功能,還支持多級嵌套路由和強大的導航守衛系統。今天,我們將深入探討如何利用 Vue Router 實現複雜的多級嵌套路由,並結合先前學習的概念,如 Pinia、Zod、Vee-Validate 等,打造一個功能豐富、類型安全的路由系統。我們還將實現一個記憶當前 tab 的左側導航欄,並探討如何使用 Vue Router 的過渡效果來增強用戶體驗。

實作步驟

步驟 1: 設置基本路由結構

首先,我們需要設置一個基本的路由結構,包括多級嵌套路由:

// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import { usePermissionStore } from '@/stores/usePermissionStore'
import { storeToRefs } from 'pinia'


export enum RoutesStatus {
  Home = 'Home',
  UserProfile = 'UserProfile',
  UserSettings = 'UserSettings',
  AdminDashboard = 'AdminDashboard',
  UserSettings = 'userAccountSettings',
  AdminUsers = 'AdminUsers',
  NotFound = 'NotFound',
  TokenFail = 'TokenFail',
}

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    component: () => import('@/layouts/MainLayout.vue'),
    children: [
      {
        path: '',
        name: RoutesStatus.Home,
        component: () => import('../pages/Home.vue')
      },
      {
        path: 'user',
        component: () => import('../pages/user/UserLayout.vue'),
        children: [
          {
            path: 'profile',
            name: RoutesStatus.UserProfile,
            component: () => import('../pages/user/UserProfile.vue')
          },
          {
            path: 'settings',
            name: RoutesStatus.UserSettings,
            component: () => import('../pages/user/UserSettings.vue')
          }
        ]
      },
      {
        path: 'admin',
        component: () => import('../pages/admin/AdminLayout.vue'),
        children: [
          {
            path: 'dashboard',
            name: RoutesStatus.AdminDashboard,
            component: () => import('../pages/admin/AdminDashboard.vue')
          },
          {
            path: 'users',
            name: RoutesStatus.AdminUsers,
            component: () => import('../pages/admin/AdminUsers.vue')
          }
        ]
      }
    ],
    { 
      path: '/:catchAll(.*)',
      name: RoutesStatus.NotFound,
      component: () => import('../pages/NotFound.vue') 
    },
    { 
      path: '/tokenFail',
      name: RoutesStatus.TokenFail,
      component: () => import('../pages/TokenNotFound.vue') 
    },
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

步驟 2: 實現基於角色的路由權限控制

接下來,我們將實現基於角色的路由權限控制,結合 Day 14 中提到的 RBAC 概念:

(檔案: src/router/index.ts)

import { PermissionRole } from '@/schemas/auth'

// ... 前面的代碼保持不變

const roleRouteMap: Record<Exclude<PermissionRole, PermissionRole.None>, string[]> = {
  [PermissionRole.Admin]: ['AdminDashboard', 'AdminUsers'],
  [PermissionRole.Manager]: ['AdminDashboard'],
  [PermissionRole.User]: ['UserProfile', 'UserSettings']
}

router.beforeEach(async (to, from, next) => {
  const permissionStore = usePermissionStore()
  const { userRole } = storeToRefs(permissionStore)
  
  if (userRole.value === PermissionRole.None) {
    await permissionStore.fetchUserPermissions()
  }
  
  if (to.name && userRole.value !== PermissionRole.None) {
    const allowedRoutes = roleRouteMap[userRole.value]
    if (!allowedRoutes.includes(to.name as string)) {
      return { name: RouteStatus.Home }
    }
  }
  
  return true;
})

步驟 3: 實現基於屬性的訪問控制 (ABAC)

我們可以進一步擴展權限控制,實現基於屬性的訪問控制 (ABAC):

(檔案:src/stores/usePermissionStore.ts)

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { PermissionRole, PolicySchema } from '@/schemas/auth'
import { usePermissionApi } from '@/composables/usePermissionApi'

export const usePermissionStore = defineStore('permission', () => {
  const userRole = ref<PermissionRole>(PermissionRole.None)
  const userAttributes = ref<string[]>([])
  const policies = ref<PolicySchema[]>([])
  
  const { getFakeUserPermissions, getFakePolicies } = usePermissionApi()
  
  const fetchUserPermissions = async () => {
    const result = await getFakeUserPermissions()
    userRole.value = result.role
    userAttributes.value = result.attributes
  }
  
  const fetchPolicies = async () => {
    policies.value = await getFakePolicies()
  }
  
  const evaluateAccess = (resource: string, action: string) => {
    return policies.value.some(policy => 
      policy.resource === resource &&
      policy.action.includes(action) &&
      policy.attributes.every(attr => userAttributes.value.includes(attr))
    )
  }
  
  return {
    userRole,
    userAttributes,
    policies,
    fetchUserPermissions,
    fetchPolicies,
    evaluateAccess
  }
})

然後在路由守衛中使用:

(檔案: src/router/index.ts)

// ... 前面的代碼保持不變

export interface PartialAuthAttribute {
  action: string;
  resource: string;
}

const isPartialAuthAttribute = (input: unknown): input is PartialAuthAttribute => {
  if (typeof input !=='object' || input === null) return false;
  if (!('action' in input) || !('resource' in input)) return false;
  if (typeof input['action'] !== 'string') return false;
  if (typeof input['resource'] !== 'string') return false;

  return true;
};

router.beforeEach(async to => {
  const permissionStore = usePermissionStore()
  
  if (permissionStore.userRole === PermissionRole.None) {
    await permissionStore.fetchUserPermissions()
    await permissionStore.fetchPolicies()
  }
  
  if (to.meta.requiredAccess) {
    if (!isPartialAuthAttribute(to.meta.requiredAccess)) {
        return { name: RoutesStatus.Home };
    }
    const { resource, action } = to.meta.requiredAccess;
    if (!permissionStore.evaluateAccess(resource, action)) {
      return { name: RoutesStatus.Home };
    }
  }
  
  return true;
})

步驟 4: 實現左側導航欄組件

現在,讓我們實現一個使用 UnoCSS 的左側導航欄組件,並在 Pinia store 中記憶當前的 tab:

(檔案: src/components/SideNav.vue)

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useNavStore } from '@/stores/useNavStore'
import { usePermissionStore } from '@/stores/usePermissionStore'
import { RoutesStatus } from '../router'

const route = useRoute()
const navStore = useNavStore()
const permissionStore = usePermissionStore()

const { currentTab } = storeToRefs(navStore)
const { userRole } = storeToRefs(permissionStore)

const navItems = computed(() => {
  const items = [
    { name: 'home', label: 'Home', routeName: RoutesStatus.Home },
    { name: 'profile', label: 'Profile', routeName: RoutesStatus.UserProfile },
    { name: 'settings', label: 'Settings', routeName: RoutesStatus.UserSettings },
  ]
  
  if (userRole.value === 'admin') {
    items.push(
      { name: 'admin-dashboard', label: 'Admin Dashboard', routeName: RoutesStatus.AdminDashboard },
      { name: 'admin-users', label: 'Manage Users', routeName: RoutesStatus.AdminUsers }
    )
  }
  
  return items
})

const setCurrentTab = (tabName: string) => {
  navStore.setCurrentTab(tabName)
}
</script>

<template>
  <nav w-64 bg-gray-800 h-screen flex flex-col>
    <div p-4>
      <h1 text-white text-xl font-bold>My App</h1>
    </div>
    <ul class="flex-1">
      <li v-for="item in navItems" :key="item.name">
        <router-link
          :to="{ name: item.routeName }"
          block px-4 py-2 text-gray-300 bg="hover:gray-700"
          :class="{ 'bg-gray-700': currentTab === item.name }"
          @click="setCurrentTab(item.name)"
        >
          {{ item.label }}
        </router-link>
      </li>
    </ul>
  </nav>
</template>

(檔案: src/stores/useNavStore.ts)

import { defineStore } from 'pinia'
import { shallowRef } from 'vue'

export const useNavStore = defineStore('nav', () => {
  const currentTab = shallowRef(RoutesStatus.Home)
  
  const setCurrentTab = (tabName: RoutesStatus) => {
    currentTab.value = tabName
  }
  
  return {
    currentTab,
    setCurrentTab
  }
})

步驟 5: 實現 Vue Router 過渡效果

最後,我們可以為路由切換添加過渡效果:

(檔案: src/App.vue)


<script setup lang="ts">
import SideNav from '@/components/SideNav.vue'
</script>

<template>
  <div class="flex">
    <SideNav />
    <main class="flex-1">
      <router-view v-slot="{ Component }">
        <transition name="fade" mode="out-in">
          <component :is="Component" />
        </transition>
      </router-view>
    </main>
  </div>
</template>


<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

步驟 6: 使用 createFetch 處理 API 請求

最後,讓我們使用 VueUse 的 createFetch 來處理 API 請求

(檔案:src/composables/useApi.ts)

import { createFetch } from '@vueuse/core'
import { useAuthStore } from '@/stores/useAuthStore'

export const useApi = () => {
  const authStore = useAuthStore()
  
  const apiFetch = createFetch({
    baseUrl: import.meta.env.VITE_API_BASE_URL,
    options: {
      async beforeFetch({ options }) {
        const token = authStore.token
        if (token) {
          options.headers = {
            ...options.headers,
            Authorization: `Bearer ${token}`,
          }
        }
        return { options }
      },
    },
    fetchOptions: {
      mode: 'cors',
    },
  })

  return {
    apiFetch,
  }
}

然後,我們可以在 usePermissionApi 中使用這個 apiFetch

(檔案: src/composables/usePermissionApi.ts)

import { useApi } from './useApi'
import { PermissionRoleSchema, PolicySchema, permissionRoleSchema, policySchema } from '@/schemas/auth'

export const usePermissionApi = () => {
  const { apiFetch } = useApi()

  const getFakeUserPermissions = async (): Promise<PermissionRoleSchema> => {
    const { data, error } = await apiFetch('/api/user-permissions').json()
    if (error.value) throw error.value
    const result = permissionRoleSchema.parse(data.value)
    return result
  }

  const getFakePolicies = async (): Promise<PolicySchema[]> => {
    const { data, error } = await apiFetch('/api/policies').json()
    if (error.value) throw error.value
    const result = policySchema.array().parse(data.value)
    return result
  }

  return {
    getFakeUserPermissions,
    getFakePolicies,
  }
}

結論

在這篇文章中,我們深入探討了如何使用 Vue Router 實現多級嵌套路由和複雜的導航守衛。我們結合了 Pinia、Zod、VeeValidate 等工具,實現了基於角色(RBAC)和屬性(ABAC)的訪問控制。我們還創建了一個記憶當前 tab 的左側導航欄組件,並為路由切換添加了平滑的過渡效果。

通過這種方法,我們不僅實現了一個功能豐富的路由系統,還確保了代碼的可維護性和可擴展性。使用 TypeScript 和 Zod 進行類型檢查和驗證,我們提高了應用程序的穩定性和可靠性。

最後,我們使用了 VueUse 的 createFetch 函數來處理 API 請求。這種方法與 Vue 3 的組合式 API 完美配合,使得我們的代碼更加簡潔和靈活。

通過這些實踐,我們不僅提高了應用程序的安全性和用戶體驗,還為未來的擴展和維護奠定了堅實的基礎。在實際的項目中,您可能需要根據具體需求進行進一步的調整和優化,但這個框架為您提供了一個強大的


上一篇
Day 17: Vee-Validate 和 Zod 結合處理複雜的表單場景 - 進階特性深度探索
下一篇
Day 19: 在 Pinia 中管理 Vue 3 應用的全局狀態與本地存儲
系列文
Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言